feat(spec): ADR-0112 error envelope gains a producer-side refusal declaration so a deliberate 5xx refusal keeps its caller-authored message (#16335) - #17090
Conversation
…eclaration `ApiErrorSchema` and `EnhancedApiErrorSchema` declare one new optional key, `refusal: true` — the producer's declaration that the 5xx it named is a deliberate refusal whose `message` is authored for the caller, so the boundary keeps it verbatim (decision batch #58, option C). The TSDoc documents the three cases side by side (undeclared 5xx: heuristic; declared fault: withheld; declared refusal: kept) and reconciles the flag with the recorded reason `userMessage` is a text-carrying field rather than a boolean beside `message`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…efusal` `pnpm --filter @objectstack/spec check:generated` on 9870511 read 14 of 15 artifacts fresh and named `content/docs/references/**` stale; `gen:docs` is the one regeneration. `authorable-surface/api.json` gains the two key rows (`api/ApiError:refusal`, `api/EnhancedApiError:refusal`) the build's `gen:schema` wrote; `api-surface/` and `json-schema.manifest/` are zero-diff by construction (export and schema names only). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…r-0112-refusal-declaration
os-regen-merge.sh step 2 took origin/main's side of the generated `content/docs/references/api/contract.mdx` (both sides had moved it — PR #16783 on main, the `refusal` row here); the pre-commit collection point then asked for `gen:schema && gen:docs` on the merged tree, which re-derives the page with both sides' content. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 134 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 6f25a22d6e2054d66ad4f58953954d36bb025348 && git checkout 6f25a22d6e2054d66ad4f58953954d36bb025348
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3c5f3c5991fbf26693a0e99b70e76adaac11b4ed 71e8db06a6d879e97dd2fb7419298d1850af8ef5 && git checkout -B drift-repro 3c5f3c5991fbf26693a0e99b70e76adaac11b4ed && git merge --no-ff 71e8db06a6d879e97dd2fb7419298d1850af8ef5
node scripts/docs-audit/affected-docs.mjs --json 3c5f3c5991fbf26693a0e99b70e76adaac11b4ed
|
Contract review at
|
| Surface | Before | After | Direction |
|---|---|---|---|
ApiErrorSchema / EnhancedApiErrorSchema / makeApiErrorSchema(...) / ErrorResponseSchema.error — input without refusal |
accepted | accepted, byte-identical output | unchanged |
same — input refusal: true |
accepted, key stripped (non-strict z.object, no .strict()/.passthrough() in either file) |
accepted, key retained; declared type gains refusal?: true |
widened (published payload gains a key) |
same — input refusal: <non-true> |
accepted (stripped) | refused at ['refusal'] |
narrowed on a spelling no in-repo producer emits (grep packages/**, apps/**: 0 emitters; the only refusal: key is SweepRefusal | null on a CLI plan object, sys-secret-orphan-sweep.ts:186) — same class as userMessage (#9934) and declaredCode (#9106), both shipped minor |
packages/spec/api-surface/api.json |
ApiError (type)/ApiErrorSchema (const) :58-60, :344-346 |
identical (names only) | unchanged |
packages/spec/authorable-surface/api.json |
— | +api/ApiError:refusal :139, +api/EnhancedApiError:refusal :586 |
+2 rows; not in files[] |
json-schema/api/{ApiError,EnhancedApiError}.json (shipped via files[], gitignored .gitignore:63) |
— | refusal: {type: boolean, const: true} per PR body |
NOT MEASURED by the reviewer (needs a build); CI Build Core + Type Check · source gates (runs check:authorable-surface/check:docs, lint.yml:4622-4700) green on this sha |
registered error codes, conversions/registry.ts |
— | — | untouched |
Clause-②: yes at PR body line 3 (line-start); check-widening-tells --declaration no fires T1 at contract.zod.ts:175 and errors.zod.ts:401 (exit 4), so yes is mechanically forced; --declaration yes exit 0; check-clause2-carriers --pair 17090 exit 0. Changeset: @objectstack/spec: minor, private unset → published. Offline gates on --base origin/main --head refs/pr-review/17090: check-changeset-no-major ✓ (level axis N/A offline; CI Check Changeset ✓ carries the PR-payload cross-check), check-adr-0087-registration ✓ "no declared-breaking changeset" (breakingDeclaration at :569-572 signals only on major / **BREAKING / BREAKING CHANGE / ! — none present, so no marker owed), check-empty-changeset ✓. No **BREAKING** banner required.
3. Enforcement / pins (ADR-0078 / ADR-0049)
Reader: none on this head, by ruling — see F1. Schema-level pins: 6 in contract.test.ts (:90-144, :737-755 through makeApiErrorSchema) and 1 in errors.test.ts (:166-191); no .skip/.only/.todo; no test pins the docblock. Structural ablation read: deleting the refusal line turns error.refusal into undefined (unknown key stripped) and safeParse({refusal:false}).success into true, so 5 contract pins + the errors pin go red and only the "absent by default" pin stays green — exactly the dev's recorded 6 failed | 87 passed of 93. The reviewer's own run is NOT MEASURED: node_modules is not installed in the review checkout and it did not install into the shared tree; CI Test Core (1/6–6/6 + rollup) is green on this exact sha. The TSDoc's sweeping claim "no site under packages/** composes a rewrapped error from a caught error's fields" survived wider probes than the PR's Object.assign survey (new XError(<caught>.message, status|statusCode|httpStatus: <caught>.…, {...err} into a throw): no hit outside the sandbox door (quickjs-runner.ts:288, closed list at :1250 verified) and the relay's own body composition (error-response.ts:2118/2249). hostImportError (types/src/node.ts:343) and protocol.ts:3144 compose fresh errors from their own fields.
4. Docs
content/docs/references/api/*.mdx — 11 generated pages, each gains the one row; errors.mdx:153 nested-shape summary also moves. Docblocks: both schemas. content/docs/releases/**: 0 edits. docs/adr/0112 not amended (would make the PR governed; #9934 set the same precedent) — noted by the PR, acceptable. F2 for the hand-written catalog page.
5. CI on head
38 check-runs on ba3d95a4: 33 success, 5 skipped, 0 failure, 0 in_progress. Skipped: Packed-tarball smoke (opt-in) ×2, Auto Label, Check PR Size, Console Pin Gate — opt-in/path filters, each with a successful sibling where one exists. Required floor Lint & Repo Gates ✓, TypeScript Type Check ✓; also Check Changeset ✓, Spec property liveness ✓, Governed Surface Queue Guard ✓, Part-of PR must not also close its card ✓. Head is 11 commits behind origin/main (3045b6fa → a27544b4); none of those commits touch any of the 17 files.
6. PR body accuracy
Verified: 17 files, +306/−1, 4 commits, merge-base 854639b3; 6+1 pins; +2 authorable rows; 11 pages; SANDBOX_ERROR_PASSTHROUGH at quickjs-runner.ts:1250; declaredServerFaultAnswer :574-588, gate :577-578, docblock :565-572; Fixes #16335 correct (all acceptance items land here; no closing keyword adjacent to #16146/#14656). Stale: F3 placeholder only.
Discharged
| Card acceptance item | Status | Evidence |
|---|---|---|
| envelope schema declares the field with a stable TSDoc | ✅ | contract.zod.ts:93-180, errors.zod.ts:390-405; three-case table :105-109; #9934 reconciliation :141-165 |
| api-surface baseline regenerated | ✅ (vacuous, as the pre-reading predicted) | api-surface/api.json byte-identical by construction (names only); the moving baseline authorable-surface/api.json regenerated (+2 rows) |
| changeset present | ✅ | .changeset/adr-0112-envelope-refusal-declaration.md, @objectstack/spec: minor |
| scope: no allow-list / no second table; default stays #5811/#5667 | ✅ | no ledger edits; packages/rest/**, packages/types/** untouched |
dispatch constraint: reconcile with contract.zod.ts:77-83 or report a fork |
✅ | reconciled in TSDoc; fork named, not taken |
{"pr":17090,"head":"ba3d95a4f3514243131a698f12589c23d49e6fcd","verdict":"PASS WITH FINDINGS","blocking":[],"clause2":"yes","semver_ok":true,"governed":false,"ci":"33 success / 5 skipped (opt-in, path filters) / 0 failure / 0 in_progress on head; required Lint & Repo Gates + TypeScript Type Check green"}维护者速读
这条 PR 给 ADR-0112 错误信封加了一个可选键 refusal: true,让生产者声明「这个 5xx 是刻意拒绝,message 是写给调用方的」,从而在中继半(#16146)落地后不再被替换成 Internal server error;本 PR 只动 packages/spec,rest/types 一行未碰。已发布契约的移动是:两个信封 schema 与 makeApiErrorSchema 派生账本都多了一个键(true 保留、false 或非布尔拒收、缺省不变),authorable-surface 加两行,api-surface 按构造零差;Clause-②: yes 与 @objectstack/spec: minor 在三条 changeset 门禁和 CI Check Changeset 上都对得上,无需 BREAKING 横幅或 ADR-0087 标记。无阻塞项;三条非阻塞发现里最要紧的是 F1:这个键在本 head 上没有读者,.describe() 文案(生成到 11 个参考页和 json-schema)用现在时说「边界会原样保留」,而在 #16146 落地前线上仍会扣住——失效方向是 fail-closed,无安全退化,但发布节奏必须把两半放在同一个 RC 里。CI 在该 sha 上 33 绿 5 跳过 0 红,两个 required job 均绿;head 落后 main 11 个提交但无文件重叠。席位下一步:修 F3 的占位符、决定 F2 走随手一行还是 docs 跟进卡、按 PASS 清双载体标签并入队,合并即刻把 #16146 从 pm:blocked 解开并派发。
Generated by Claude Code
… tighten the survey sentence Contract review on PR #17090 (B1): `@objectstack/rest` withholds a declared 5xx at two arms with byte-identical output — `declaredServerFaultAnswer` (two callers: the `/data` classifier and the analytics door) and `resolveErrorResponse`'s own 5xx passthrough arm, which every route reporting through `handleRouteError` / `sendThrownError` reaches, the `/references` door among them. The TSDoc and the changeset named only the first; both now name both and state that #16146 must move both. Also: the route-local patch is PR #16143 (not #16146, which retires it); the rewrap survey now covers all of `packages/**` — 13 `Object.assign` sites, the three copying off a caught error (`driver-sql`) copy `code`/`cause` and no `status`, and the one in-place `message` rewrite (`domains/actions.ts`) is on a `SandboxError` that cannot carry the flag; both `.describe()` strings carry the relay-half caveat so the generated pages stop advertising a reader that does not exist until #16146 lands. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…r-0112-refusal-declaration
…f caveat) os-regen-merge.sh step 2 took origin/main's side of the generated `content/docs/references/api/protocol.mdx` (both sides moved it); the pre-commit collection point asked for `gen:schema && gen:docs` on the merged tree. The same run re-derives every page that inlines the envelope with the `.describe()` relay-half clause added for the contract review's N1. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…n issue id `check:doc-authoring` refuses an issue id in `.describe()` prose — it projects into customer-facing references and the shipped JSON schema, where `#NNNN` resolves to nothing. The caveat stays in both describes, spelled as the fact it states: until the REST withhold arms read the declaration, a declared refusal is still withheld. Generated pages and schemas re-derived. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
Contract review at
|
| Commit | Hunks (file:line on the new head) | Judgment |
|---|---|---|
6b47277b |
.changeset/adr-0112-envelope-refusal-declaration.md:17 — relay half now "the two withhold arms"; route-local patch attributed to PR #16143 |
prose only; grade unchanged |
contract.zod.ts:125-127 — patch = PR #16143, retired by #16146 |
matches the body the prior verdict read; #16143 itself NOT re-verified | |
contract.zod.ts:132-142 — "Read once, at the withhold arms": declaredServerFaultAnswer and resolveErrorResponse's own 5xx arm; "#16146 must move BOTH" |
verified on origin/main: declaredServerFaultAnswer error-response.ts:574, callers only classifyDataError :1409 and the analytics route rest-server.ts:11190; resolveErrorResponse :1976, guard :2015-2020 keeps a declared 5xx band away from mapDataError, 5xx arm :2116-2124; entered from sendThrownError :1850 and handleRouteError :2439. Beyond the prior findings and material to #16146's scope, not to this PR's accept set |
|
contract.zod.ts:157-160 — "only consumers are the two withhold arms" |
consistent with the above | |
contract.zod.ts:173-180 — survey: 13 Object.assign sites under packages/**; the 3 in driver-sql copy code/cause, no status; the one in-place message rewrite (domains/actions.ts) is on a SandboxError |
verified: 19 grep hits − 5 CHANGELOG − 1 comment = 13; sql-driver.ts sites copy only code/cause (head tree :12352/:15795/:16164); actions.ts:853 gated on innerMessage; SANDBOX_ERROR_PASSTHROUGH quickjs-runner.ts:1250 unchanged. Replaces the prior "no site … composes a rewrapped error" claim with the measured one — strictly more accurate |
|
contract.zod.ts:187-193, errors.zod.ts:401-406 — .describe() gains the relay caveat, first spelled with #16146 |
F1 route B; the #16146 spelling went red (below) |
|
57e6720a |
merge of origin/main 0da638cd (140 main-side files) |
see §merge |
4e9a6c5a |
11 content/docs/references/api/*.mdx regenerated (67/67) |
generated |
e7305600 |
contract.zod.ts:189-191, errors.zod.ts:404-405 — caveat re-spelled without an issue id ("until the REST withhold arms read it, a declared refusal is still withheld"); 11 pages regenerated |
fixes the delta's own CI red; final describe text is true on this head |
Published face vs the prior reading: the accept set did not move. contract.zod.ts with comments stripped is identical to ba3d95a4 except the describe string; errors.zod.ts likewise; contract.test.ts, errors.test.ts, authorable-surface/api.json byte-identical prior→r2. What moved on the published face is only text: the two .describe() strings (→ json-schema/api/{ApiError,EnhancedApiError}.json description, NOT MEASURED without a build; Build Core + Type Check · source gates green), the TSDoc (ships in src/**/*.zod.ts), 66 generated rows across 11 pages (0 stale spellings: git grep "REST withhold arms" hits all 11; relay half: #16146 / so boundaries keep it verbatim 0 hits), the changeset prose. The prior verdict's accept-set table, ledger reading (no liveness/api.json row owed) and api-surface zero-diff stand unchanged.
Merge (57e6720a): PR hunks byte-identical across it for 16/17 files (diff(B,6b47277b) vs diff(M2,merge) per file). protocol.mdx is the one exception: the merge kept the PR side whole (main's dashboards row change not carried — content/docs/references/** is regenerated whole under the os-regen driver), and 4e9a6c5a re-derived it. On the final head protocol.mdx differs from origin/main by the 3 +refusal rows only and its dashboards row equals main's; check:docs (lint.yml:4733, inside Type Check · source gates) green on the head.
Prior findings status
- F1 — addressed. Route B taken and exceeded: the caveat is in both describes (
contract.zod.ts:189-190,errors.zod.ts:404-405) and in all 66 generated rows; the TSDoc names both arms. The review's suggested spelling ("relay: [finding] declaredServerFaultAnswer cannot tell a deliberate REFUSAL from a server FAULT, so every producer-declared 5xx refusal loses its prose on the wire #16146") is refused bycheck:doc-authoring(issue ids in.describe()prose); the dev's id-less spelling is the correct one. Body 验收备注 hands [finding] declaredServerFaultAnswer cannot tell a deliberate REFUSAL from a server FAULT, so every producer-declared 5xx refusal loses its prose on the wire #16146 the removal of the clause + regeneration. - F2 — not addressed.
content/docs/api/error-catalog.mdxbyte-identical toorigin/main;interface EnhancedApiError:664-679still lacksrefusal?: true(anduserMessage,declaredCode). No docs-only follow-up card exists (issues created after 08:36Z: none naming the page). The bot's other two pages (api/index.mdx:201,client-sdk.mdx) only name the symbol/route — no F2-class drift there. - F3 — addressed. Body edited 12:49:31Z:
--pair 17090present,PR_NUMBERgone.
Clause-② reading (re-derived; unchanged)
Clause-②: yes at body line 3. check-widening-tells --declaration no on origin/main...r2 fires T1 at contract.zod.ts:187 and errors.zod.ts:401 (exit 4; line moved from 175 by TSDoc growth), --declaration yes exit 0. check-clause2-carriers --pair 17090 exit 0 ("both carriers agree"). @objectstack/spec: minor still correct (text-only delta). Offline on --base origin/main --head refs/pm-review/17090-r2: check-adr-0087-registration 0, check-changeset-no-major 0, check-empty-changeset 0; CI Check Changeset success ×2.
Governed surface / protocol label
Non-governed: GOVERNED_SURFACES (check-governed-merges.mjs:793) = docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md; none in the 17 files (main's merge brought such commits into the branch history, but origin/main...r2 is the 17 files). Governed Surface Queue Guard green. No protocol:* label and none expected: .github/labeler.yml maps protocol:* only to packages/spec/src/{data,ui,system,ai}/**; src/api/** is unmapped. Labels on head: documentation, size/m, tests, tooling, needs:contract-review; card: enhancement, priority:p2, pm:dispatched, domain:spec, needs:contract-review — both carriers still carry the gate.
CI on head
40 check-runs on e7305600 at 12:51Z: 36 success, 4 skipped, 0 failure, 0 in progress. Skipped: Console Pin Gate, Packed-tarball smoke (opt-in), and the 12:49Z edited-event re-runs of Auto Label / Check PR Size (each has a successful push-event sibling). Required floor Lint & Repo Gates ✓ (12:49:52Z), TypeScript Type Check ✓; Test Core rollup + 6/6, Build Core, Type Check · source gates, Spec property liveness, Part-of PR must not also close its card ✓. Intermediate heads: 4e9a6c5a Lint & Repo Gates failed at step 53 "Doc/skill authoring guard" on the #16146 ids at contract.zod.ts:189 / errors.zod.ts:404 — caused by the delta's first spelling, fixed by e7305600 (step 53 ✓ on head). 6b47277b TypeScript Type Check failure is the aggregator refusing four cancelled lanes after the head moved 17 s later — not a delta defect.
New findings
F4 (non-blocking) — no patch-round report on either carrier: zero comments on #17090 or #16335 after the director's 08:36/08:38Z notes, and the seat's adoption is unposted. The body now claims re-runs on e73056007 (93/93, ablation at ba3d95a4 with the schema line unchanged since, check:generated 15/15, 103 derived gates) but the card carries none of it. CI on the head is the reading of record and is fully green, so this does not move the verdict; the seat should post the round report (or point the card at the body) when it adopts and clears.
F5 (non-blocking, cosmetic) — body Reconciliation §2 cites the driver-sql sites at :12354/:15797/:16166; on the head tree the Object.assign lines are :12352/:15795/:16164 (the cited numbers are each site's cause: line). Same three sites; the TSDoc cites file names only, which is the durable spelling.
Acceptance notes
- Blocking set stays empty; prior PASS WITH FINDINGS carries onto
e7305600. Independence pair unchanged (implementer =domain:specseat). - Before ready: choose F2's route (one-line edit here or a filed docs-only card covering
refusal/userMessage/declaredCode); post the F4 round report. Then clear both carriers per the PASS, ready → auto-merge; on merge un-block [finding] declaredServerFaultAnswer cannot tell a deliberate REFUSAL from a server FAULT, so every producer-declared 5xx refusal loses its prose on the wire #16146 immediately, whose scope is now explicitly both withhold arms plus removing the describe caveat and regenerating 66 rows + 2 shipped schemas. - NOT MEASURED here: the shipped
json-schema/api/*.jsondescription text (no build), the dev's local test/gate runs one73056007(CI covers), PR rest/meta: the /references door answers both 501 refusals in one ADR-0112 envelope #16143 as the route-local patch (taken from the body, consistent withrest-server-meta-references-refusal-envelope.test.tson main).
Generated by Claude Code
…r-0112-refusal-declaration
… — the third is `@objectstack/runtime`'s dispatcher exit The published TSDoc, both `.describe()` caveats and the changeset treated the two `@objectstack/rest` arms as the closed set. `errorResponseBase` (`packages/runtime/src/dispatcher-plugin.ts`, gated on `serverFaultProvenance(thrown) === 'declared'`) withholds every declared 5xx too, never consults `declaredServerFaultAnswer`, is mounted by `objectstack serve`, and emits the very envelope this field lands on. The prose now names it, hands the runtime exit to #16146's sub-issue #17153, and: - drops the "13" census count, narrows the copy-off-a-caught-error claim to the `Object.assign` shape, and names the two overlay-delete rewraps in `metadata-protocol/src/protocol.ts` that copy `status` and carry `code`/`userMessage` through `carry*` helpers (⛔ no `carryRefusal`); - notes the analytics dataset door calls arm 1 bare (no `withDeclaredUserMessage`), so the REST arms are not byte-identical when `userMessage` is declared; - replaces the `/references` routing attribution: the throw spells `status`, so it takes `resolveErrorResponse`'s status passthrough into that function's own 5xx arm; a `statusCode` spelling falls to `mapDataError` and arm 1; - qualifies the table's second row as the rule at the three arms, not a platform-wide invariant (the heuristic-only doors read no declaration); - pins the shipped JSON Schema's `const: true` on both envelopes through the generator's own `toJSONSchema` options; - adds the `refusal?: true` line to the hand-written `EnhancedApiError` excerpt in `content/docs/api/error-catalog.mdx`; - regenerates the 66 reference rows for the id-less caveat that no longer says "REST". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…r-0112-refusal-declaration
…r-0112-refusal-declaration
…he caught error, so say "and/or" The sentence grouped it with the `driver-sql` rewraps as copying "`code` and `cause`"; the objectql site sets `code` as a literal and copies only `cause`. The claim that matters — none of them copies `status` — is unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…r-0112-refusal-declaration
Fixes #16335
Clause-②: yes
Spec half of #16146, under director decision batch #58 (2026-09-06, option C; maintainer 「同意」): the refusal/fault distinction is a producer-side declaration on the published ADR-0112 envelope — not a status heuristic and not a second allow-list. This PR declares that field. The relay half — the withhold arms learning to read it, plus retiring the route-local patch from PR #16143 — is carried by #16146 for the
@objectstack/restpair and by its sub-issue #17153 for the@objectstack/runtimedispatcher exit. ⛔packages/rest/**,packages/runtime/**andpackages/types/**are untouched here.What changed
packages/spec/src/api/contract.zod.ts—ApiErrorSchemagains one optional key,refusal: z.literal(true).optional(), with a TSDoc that (a) documents the three cases side by side, (b) states the semantics and names all three withhold arms the relay must move, and (c) reconciles the flag with theuserMessagedesign note at:77-83.packages/spec/src/api/errors.zod.ts—EnhancedApiErrorSchemagains the same key, its TSDoc pointing at the base for the rationale (theuserMessagepattern from contract: a hook refusal has no way to mark its message user-facing — the console's 403 substitution (ruled in #3821) needs a producer-side opt-in channel #9934)..describe()strings carry the relay caveat in present-tense fact form — "until the withhold arms read it, a declared refusal is still withheld" — so the 66 generated reference rows and the two shipped JSON schemas do not advertise a reader that does not exist yet. The caveat carries no issue id:check:doc-authoringrefuses one in customer-facing prose.content/docs/api/error-catalog.mdx— the hand-writteninterface EnhancedApiErrorblock gainsrefusal?: true;. This block is authored, not generated, so the 66 generated rows never reached it (director finding F2).contract.test.tsanderrors.test.ts: kept verbatim besidemessage; absent by default;refusal: falserefused at['refusal']withinvalid_value; a non-boolean refused; orthogonal touserMessage; the docblock's promise that a base-envelope field reaches the downstream ledger throughmakeApiErrorSchema; and az.toJSONSchemapin on each envelope asserting the shipped grammar is{"type":"boolean","const":true}..changeset/adr-0112-envelope-refusal-declaration.md—@objectstack/spec: minor.packages/spec/authorable-surface/api.json(+2 rows) and 11 pages undercontent/docs/references/api/.The THREE withhold arms — and the third is outside
@objectstack/restThe at-tier contract review's binding item B1': an earlier draft called the two
@objectstack/restarms a closed set, in prose that ships (packages/spec'sfiles[]carriessrc/**/*.zod.ts, so this TSDoc goes into the tarball verbatim). It is not closed. Measured on this tree:@objectstack/restdeclaredServerFaultAnswer(error-response.ts) — its only two callers are the/dataclassifier (rest: a declared 5xx answers500 ANALYTICS_QUERY_FAILEDon/analytics/dataset/querywhere/dataanswers the declared status and code #11718), which wraps it inwithDeclaredUserMessage, and the analytics dataset door, which calls it bare, souserMessagerides the first and not the second;@objectstack/restresolveErrorResponse's own 5xx passthrough arm, reached throughhandleRouteError/sendThrownError. The/referencesthrow spellsstatus, so it takes that function's status passthrough into this arm; astatusCode-spelled 5xx falls tomapDataErrorand arm 1 instead. Arms 1 and 2 compose the same bytes;@objectstack/runtimeerrorResponseBase(dispatcher-plugin.ts), the kernel dispatcher plugin's thrown exit, gated onserverFaultProvenance(thrown) === 'declared'(@objectstack/types) — any 5xx whose throw declared a status,codeor not.objectstack servemounts it viacreateDispatcherPlugin; it answersPOST /analytics/query; it emitsErrorResponseSchema, the very envelopeEnhancedApiErrorSchemadescribes; and it is pinned bydispatcher-plugin.declared-5xx-prose-withhold.test.ts(runtime: a declared 5xx carrying NOcodekeeps its prose on/analytics/querywhere/datawithholds it unconditionally #12281). It never consults arm 1, so moving the REST pair alone leaves it withholding.Two readings behind arm 3, both verified here rather than taken on report:
serverFaultProvenancereturns'declared'wheneverdeclaredStatus !== undefined, andresolveThrownHttpErrorsetsdeclaredStatusfrome.statusore.statusCode— hence "a declaredstatusorstatusCode" in the TSDoc. The TSDoc also states explicitly that the table's second row is the rule at these three arms, not a platform-wide invariant: every other 5xx door (HttpDispatcher's returned exit,endpointErrorAnswer,package-routes'sendThrownError, the hono auth door) reads no declaration and withholds by the leak heuristic alone.Acceptance grep for B1', on this head:
The rewrap survey — no count, and the
carry*site namedThe earlier draft's "13
Object.assignsites" is gone: the review counted 20 on two trees with its own spelling, so a census number written into shipped prose rots. The TSDoc now states the shape and names the sites, with the fail-closed conclusion unchanged:SANDBOX_ERROR_PASSTHROUGH) and this field is not on it.Object.assignerror-composition sites underpackages/**(non-test), the ones that copy anything off a caught error copy onlycodeand/orcause—drivers/driver-sql, andobjectql's autonumber-collision rewrap, whosecodeis a literal and whose only copy off the caught error iscause— and neverstatus.metadata-protocol/src/protocol.tsDO copystatus(with a 500 fallback) onto a rewrittenmessageand carrycodeanduserMessagethroughcarryCatalogedErrorCode/carryDeclaredUserMessage. Nothing carries this flag, so a refusal crossing them is withheld as a fault — and the TSDoc now records that ⛔ the relay must not add acarryRefusalthere, since that would putoverlayDeleteFailureMessage's platform prose on the flag channel, the promotion theuserMessagenote refused.messagerewrite that keepsstatus/code(runtime/src/domains/actions.ts) is on aSandboxError, which cannot carry the flag.What the checked-in baselines see — corrected
The earlier draft claimed the pins were the only in-tree evidence of the value rule. That was wrong, and it is the inverse of this lane's failure mode: it described a green gate that IS answering the question as one that is not.
Four baselines are indeed blind to
const: true—authorable-surface/records key names,api-surface/export names,json-schema.manifest/schema names, and the liveness ledger is theapimetadata type's. But a fifth checked-in artifact is not blind:build-docs.tsrenders the type column fromprop.const, so all 66 generated reference rows read`true`.check:docs(build-docs.ts --check, exit 1 on drift) is a required status check with no paths filter. Downgradingz.literal(true)toz.boolean()therefore either reds that required check or shows up as a 66-row`true`→`boolean`diff in review. Plus, now, the twoz.toJSONSchemapins added this round assert the shipped{"type":"boolean","const":true}directly.Reconciliation with
contract.zod.ts:77-83The #9934 note refuses "a boolean beside
message" for the END-USER channel: that channel's consumer is the console renderer across the wire, #3821's generic substitution must hold against every boundary that rewrapsmessage, and a mark separated from its text could be promoted onto platform prose. That argument does not transfer here:codealready qualifiesstatusfordeclaresServerFault. Its only consumers are the three withhold arms, each readingstatus,codeand the flag off the SAME thrown object in ONE read before composing a body. There is no second channel to promote prose into; the flag only switches the withhold off, and what reaches the wire is the samemessagea declared 4xx already discloses.trueis therefore the only value: a fault already has its own spelling, sorefusal: falsefails parse rather than becoming a third state every consumer would have to interpret.If a reader judges that (1)-(3) do not survive, the fork is: keep the producer-side declaration but carry the text (a
refusalMessage-shaped field), which would makemessageon a 501 stayINTERNAL_ERROR_MESSAGEand move the prose to a sibling — the opposite of what the card asks. I did not take that fork; it is named so the choice is visible.Verification
All readings on the final head
71e8db06a(origin/main4261fbc80merged throughscripts/pm/os-regen-merge.sh). Heavy runs went throughscripts/pm/os-verify-lock.sh; every exit code was redirected to disk before it was read.pnpm --filter @objectstack/spec test→Test Files 470 passed (470),Tests 13207 passed (13207),TEST_EXIT=0.pnpm --filter @objectstack/spec typecheck→TYPECHECK_EXIT=0, includingcheck:test-typecheckover the test layer (54 files / 261 errors / 145 pinned signatures held in the shrink-only ledger).pnpm --filter @objectstack/spec build && gen:docs && check:generatedunder one lock hold:✓ All 15 generated artifacts are up to date,VERDICT command-exit 0. Working tree clean afterwards, so the merge introduced no regeneration drift.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 103 families on this head; all 103 were run, each exit code written to disk first, then reconciled:✓ dispatch-gates --ran: 103 derived famil(ies) accounted for — 103 run, 0 NOT-MEASURED(exit 0). 101 measured green. 2 ran but measured nothing and are reported as NOT MEASURED, never as passes:check:dual-build-cjs-loadsandcheck:type-check-debt, both exit 3 with the gate's ownPREREQUISITE NOT METline ("⛔ This is NOT a pass: nothing was measured") because each needs a whole-workspace build. Those are CI's run.eslint.config.mjsitself —files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']; (2) file count read from--format json: 4 files, 0 errors, 0 warnings, exit 0, over the 4 TS files this diff touches; (3) invariance — everyparserOptionsblock in the config is{ecmaVersion, sourceType}only, with noprojectand noprojectService, and the config states at:328that no typed@typescript-eslintrules run for any file, backed by its own planted positive control. With no type-aware linting, this diff cannot move the verdict on any file it does not touch. The repo-widepnpm lintis CI's run.验收备注
userMessage); the ruling is recorded on [finding] declaredServerFaultAnswer cannot tell a deliberate REFUSAL from a server FAULT, so every producer-declared 5xx refusal loses its prose on the wire #16146 (comment 5559950291) and ledgered on [PM seat] director(项目总监) — 🟢 os-zhuang(第 15 场)· ⏹️ 第 16–20 场已收班(第 20 场 = 总监契约复审session_01Tep4AYXZvyBA7jsvne5KZV,收班 2026-09-09T07:20Z) #12708. Editingdocs/adr/**would make this a governed-surface PR. 承接者:无.SANDBOX_ERROR_PASSTHROUGHextended only if tenant sandbox bodies are meant to declare refusals — and on that door the caller-addressed text isinnerMessage, notmessage, so the read there must go throughsandboxBusinessMessage. 承接者:[finding] declaredServerFaultAnswer cannot tell a deliberate REFUSAL from a server FAULT, so every producer-declared 5xx refusal loses its prose on the wire #16146.rest-server.ts:1377's comment and PR rest/meta: the /references door answers both 501 refusals in one ADR-0112 envelope #16143's body both say the/references501 reaches the wire throughdeclaredServerFaultAnswer; the trace says it takes arm 2's status passthrough. The two arms compose identical bytes, so no driver reading tells them apart — a stale comment, not a behaviour bug. 承接者:[finding] declaredServerFaultAnswer cannot tell a deliberate REFUSAL from a server FAULT, so every producer-declared 5xx refusal loses its prose on the wire #16146..describe()strings, plus regeneration of the 66 reference rows and 2 shipped JSON schemas, once they read the field. No gate forces this. 承接者:[finding] declaredServerFaultAnswer cannot tell a deliberate REFUSAL from a server FAULT, so every producer-declared 5xx refusal loses its prose on the wire #16146 and The runtime dispatcher exit (errorResponseBase) is the third withhold arm — it must readrefusaltoo, and it lives outside@objectstack/rest#17153.Authored in Claude Code session
session_01MkQhmuuJAVDjmeWNixwDDH(patch round; the contract-review seat's carrierneeds:contract-reviewstays on, untouched).Generated by Claude Code